KAFKA-20578: Initial producer incremental allocation for uncompressed data - #22654
KAFKA-20578: Initial producer incremental allocation for uncompressed data#22654lianetm wants to merge 69 commits into
Conversation
| Importance.MEDIUM, | ||
| CommonClientConfigs.CLIENT_DNS_LOOKUP_DOC) | ||
| .define(BUFFER_MEMORY_CONFIG, Type.LONG, 32 * 1024 * 1024L, atLeast(0L), Importance.HIGH, BUFFER_MEMORY_DOC) | ||
| .define(BUFFER_MEMORY_ALLOCATION_STRATEGY_CONFIG, |
There was a problem hiding this comment.
Should we mark this as internal to prevent it from leaking into 4.4 before the feature is fully implemented?
There was a problem hiding this comment.
Yes, done, and created https://issues.apache.org/jira/browse/KAFKA-20763 to publish it once the feature is complete
| // pool memory by completing batches). On exhaustion, close the batch (making it | ||
| // drainable) and fall through to the blocking first-record path next iteration. | ||
| try { | ||
| extensionChunks = chunkedFree.allocateChunks(extensionBytes, 0L); |
There was a problem hiding this comment.
Why do we need to do a special non-blocking call? In the existing logic, if an allocation request is blocked, all existing ProducerBatches become immediately drainable.
There was a problem hiding this comment.
Not strictly needed, agreed, it was just to fail fast and avoid what seemed like a "waisted" wait here, exactly because of the fact you mention, that blocking would make all open batches drainable, including this one we want to extend (and this is the trick I was trying to avoid, updated the comment that was misleading).
So with a blocking call, even if we get the pool memory we need in time, the batch would probably be gone/drained, and we would need to start a new batch anyways (so opting for non-blocking just to go straight to the new-batch phase if pool is exhausted when extending).
That being said, there is a case where blocking here would seem better, that's if the pool is exhausted at this point but it frees-up before the batch we're extending is drained. But that one didn't seem common in practice given that the sender drains batches before polling (so by the time memory frees up from a request that compelted, the batch we're waiting to extend would be drained already). Makes sense?
| last.closeForRecordAppends(); | ||
| } | ||
| } | ||
| continue; |
There was a problem hiding this comment.
It may take a bit of time for the closed batches to be drained. If we continue here, it seems that the client will just busy-loop until some batches are drained and some free space becomes available in buffer pool?
There was a problem hiding this comment.
even without the drain the closeForAppends should be enough to make that the next iteration find that the stream is closed => isFull => !hasRoom so starting a new batch (blocking, no busy loop). Makes sense? Added a test testExhaustedExtensionFallsBackToBlockingNewBatchPath to show the behaviour.
| * them, and retries. Otherwise defers to the parent. | ||
| */ | ||
| @Override | ||
| protected RecordAppendResult tryAppend(long timestamp, byte[] key, byte[] value, Header[] headers, |
There was a problem hiding this comment.
It's a bit awkward to have a return value of null and RecordAppendResult.needsExtension. Could we introduce a non-null value to indicate the batch is full?
There was a problem hiding this comment.
Agreed, done. Introduced needsNewBatch (aligned with needsBufferExtension)
| // ProducerBatch), which can't take extension chunks. Only attach to a | ||
| // writable chunked batch; otherwise refund the chunks and re-evaluate. | ||
| if (last instanceof ChunkedProducerBatch && last.isWritable()) { | ||
| ((ChunkedProducerBatch) last).addBuffers(extensionChunks); |
There was a problem hiding this comment.
I guess two concurrent clients could add buffers exceeding the batch size? Those buffers won't be used, but can only be freed after the batch is drained.
There was a problem hiding this comment.
Correct, there could be over-reservation at this point with concurrent appends. As for how the extra buffers would be used/released, I see the following cases:
- used right away: next loop iteration -> the loser's
tryAppendright after this line fails withneedsExtension(the winner already took the free capacity that was used to calculate this extension). Then the loser loops and tries again (recomputes the extension needed, so it uses what it had added concurrently + more) - left unused, freed when batch drains (case you mentioned) -> if the loser's record can't land in this batch (batch closes and loser's record goes into new batch).
Added 2 tests to cover this: testConcurrentExtensionRaceDoesNotOverflowChunkedStream and testBatchSizeLimitRespectedDespiteOverReservation
There was a problem hiding this comment.
Here is a related case. Two concurrent producer each allocated its own extensionChunks. It's possible that the first producer's allocation is enough to fit the records from both producer. It would be useful to document that this logic optimizes for the common case, but can lead to slightly temporary over allocation.
There was a problem hiding this comment.
Yes, agreed, added a comment describing it.
| long remainingBytes = memoryRequired - (long) pooled.size() * chunkSize; | ||
| if (remainingBytes > 0) { | ||
| // remainingBytes <= memoryRequired <= totalMemory (validated above), so the int cast is safe. | ||
| freeUp((int) remainingBytes); |
There was a problem hiding this comment.
This is a no-op since all pooled chunks have been used if we reach here.
| pooled.add(free.pollFirst()); | ||
| long remainingBytes = memoryRequired - (long) pooled.size() * chunkSize; | ||
| if (remainingBytes > 0) { | ||
| // remainingBytes <= memoryRequired <= totalMemory (validated above), so the int cast is safe. |
There was a problem hiding this comment.
Why is remainingBytes guaranteed to be an int? memoryRequired could be larger than int and pooled.size() initially could be 0.
There was a problem hiding this comment.
yup, good catch, seemed safe here but it's not because of how memoryRequired is rounded up higher up. This code here does not exist anymore though so no changes (the other place where a similar unsafe op was being done to calculate stillNeeded went away too, removed with the comment/fix below)
| } | ||
| long stillNeeded = memoryRequired - (long) pooled.size() * chunkSize - accumulated; | ||
| if (stillNeeded > 0) { | ||
| freeUp((int) stillNeeded); |
There was a problem hiding this comment.
This may be ok, but it's a bit weird to free up the chunks only to be reallocated again. Here is an alternative that doesn't require a freeup() call.
// Reuse pooled chunks first. If a reused chunk covers a slot we already reserved as
// raw bytes in an earlier iteration, hand that raw reservation back to the pool.
while (pooled.size() < numChunks && !free.isEmpty()) {
pooled.add(free.pollFirst());
if (accumulated >= chunkSize) { // accumulated is always chunk-aligned here
accumulated -= chunkSize;
this.nonPooledAvailableMemory += chunkSize; // refund → available to other waiters
}
}
// Reserve raw memory for any still-uncovered chunks, in whole chunks.
while (pooled.size() + (int)(accumulated / chunkSize) < numChunks
&& this.nonPooledAvailableMemory >= chunkSize) {
this.nonPooledAvailableMemory -= chunkSize;
accumulated += chunkSize;
}
|
|
||
| // Take pool chunks first, then reserve non-pool bytes for the remainder. | ||
| while (pooled.size() < numChunks | ||
| && (long) (pooled.size() + 1) * chunkSize + accumulated <= memoryRequired |
There was a problem hiding this comment.
The first condition is redundant, given the second one.
(pooled+1)*chunkSize + accumulated ≤ memoryRequired
⇒ (pooled+1)*chunkSize ≤ memoryRequired − accumulated ≤ memoryRequired = numChunks*chunkSize
⇒ pooled+1 ≤ numChunks
⇒ pooled < numChunks
There was a problem hiding this comment.
Agreed (the condition changed though, with the comment above, and does not have the redundancy anymore, so no changes directly for this)
| // On failure (timeout / close / interrupt), refund the non-pool bytes taken. | ||
| // Pool chunks already in `pooled` are returned to `free` separately by the | ||
| // outer catch. | ||
| this.nonPooledAvailableMemory += accumulated; |
There was a problem hiding this comment.
Could we return accumulated and pooled chunks in the same place? For example, we can set a flag like allocationCompleted to replace accumulated = 0. Then we can free both accumulated and pooled chunks if the flag is not set.
There was a problem hiding this comment.
Done. Introduced the allocationCompleted flag and now all refunds happen together in a finally, much better indeed.
|
Thanks for the review @junrao , all comments addressed. About the high level one (this) on the overlap between RecordAccumulator and ChunkedRecordAccumulator, totally agree. I refactored to extract and reuse different common bits for now (keeping both classes still, just common helpers, along the lines of the alternative 1). Compression will touch the core of this, so wonder if better to wait to see how those changes fit here, to see how much common surface we still have and then decide if continue with the helpers approach, taking them a step further, or go with some like the alt 2 approach. Makes sense? if so I would create a jira on me to review/dedup along with the compression changes. |
| * strategy), {@link #CHUNKED} only {@link #allocateChunks} (the incremental strategy). Fixed at | ||
| * construction so the two are never mixed on the same pool. | ||
| */ | ||
| public enum AllocationMode { SINGLE, CHUNKED } |
There was a problem hiding this comment.
Would it better to use FULL and INCREMENTAL?
| throw new IllegalStateException("needsNewBatch path reached without an allocated buffer stream"); | ||
| // Reuse the new-batch size estimate as the write-limit basis. | ||
| // TODO: review when compression is supported. | ||
| final NewBatchBuffer pending = newBatch; |
There was a problem hiding this comment.
pending => pendingNewBatch?
| */ | ||
| private static final class NewBatchBuffer { | ||
| final ChunkedByteBufferOutputStream stream; | ||
| final int size; |
|
|
||
| // Now free chunks one at a time, allowing the multi-chunk request to accumulate. | ||
| p.deallocate(h1); | ||
| Thread.sleep(50); |
There was a problem hiding this comment.
Why does it need to sleep for 50ms, which is long for a unit test? Ditto below.
There was a problem hiding this comment.
yeap not needed in this case really, and the other one can be better done with a waitForCondition on the available memory, done.
| } catch (Throwable th) { | ||
| err.set(th); | ||
| } | ||
| }, "slow-success"); |
There was a problem hiding this comment.
slow-success => chunked-waiter ?
|
Thanks @junrao ! All comments addressed. |
| // Use the chunked path only when a batch is at least one full chunk | ||
| // (batch.size >= CHUNK_SIZE). Below that, a batch can't fill even one chunk, so chunking | ||
| // would over-reserve and the producer falls back to the full strategy instead. | ||
| boolean useIncremental = incremental && batchSize >= ChunkedRecordAccumulator.CHUNK_SIZE; |
There was a problem hiding this comment.
If we switch automatically to full because of the batch size, it might be useful to log a warning.
| // A single call can guarantee at most `chunkSize` of space (the stream advances one chunk | ||
| // at a time). Callers needing more attach chunks via addBuffers first. write(byte[]) loops | ||
| // across chunks, so contiguous capacity isn't required. | ||
| ensureChunkCapacity(Math.min(remainingBytesRequired, chunkSize)); |
There was a problem hiding this comment.
Hmm, this still seems to only work if remainingBytesRequired is 1. If it's larger than 1 and the current chunk doesn't have enough remaining bytes, we move to the next chunk. A subsequent write will write on the new chunk, the remaining bytes on the previous chunk are wasted.
There was a problem hiding this comment.
yeap, agreed, fixed. Also added a todo to review this bit when compression and the need to grow lands
| /** | ||
| * Appends the record after checking there's chunk capacity for it. | ||
| * At this point it's expected that the allocated chunks have | ||
| * capacity for the record because the accumulator never routes an |
There was a problem hiding this comment.
At this point it's expected that the allocated chunks have capacity
Hmm, is this true? This method could return null because the batch is full, right?
There was a problem hiding this comment.
right, updated the javadoc to clarify
| /** Total available memory is the sum of nonPooledAvailableMemory and the number of byte buffers in free * poolableSize. */ | ||
| private long nonPooledAvailableMemory; | ||
| /** Lock held for any read or write of {@link #free}, {@link #waiters}, {@link #nonPooledAvailableMemory}, or {@link #closed}. */ | ||
| protected final ReentrantLock lock; |
There was a problem hiding this comment.
Does this still need to be protected? Ditto for other fields. We can also remove the addition of BufferPool to spotbugs-exclude.xml.
There was a problem hiding this comment.
agreed, all private now and cleaned up the spotbugs
| * Wake the longest-waiting thread if any memory (pooled or non-pooled) is available. | ||
| * Must be called with {@link #lock} held. No-op if no waiters or no memory is free. | ||
| */ | ||
| protected void signalNextWaiterIfMemoryAvailable() { |
| * full strategy (allocate) and the incremental strategy (ChunkedRecordAccumulator), | ||
| * so both update the same buffer-exhausted metrics. | ||
| */ | ||
| protected void recordBufferExhausted() { |
There was a problem hiding this comment.
This can have package level visibility.
| * waiter. Acquires {@link #lock} internally. Used by callers | ||
| * that reserve memory and then need to roll back the reservation (e.g., upon errors). | ||
| */ | ||
| protected void releaseReservedBytes(long bytes) { |
| * buffers (if needed). Must be called with {@link #lock} held. | ||
| */ | ||
| private void freeUp(int size) { | ||
| protected void freeUp(int size) { |
| public class ChunkedProducerBatch extends ProducerBatch { | ||
|
|
||
| public ChunkedProducerBatch(TopicPartition tp, MemoryRecordsBuilder recordsBuilder, long createdMs) { | ||
| super(tp, recordsBuilder, createdMs); |
There was a problem hiding this comment.
Should we validate that recordsBuilder.bufferStream() is chunked?
| newBatchSize, chunkedFree.poolableSize(), topic, effectivePartition, maxTimeToBlock); | ||
| List<ByteBuffer> initialChunks; | ||
| try { | ||
| initialChunks = chunkedFree.allocateChunks(newBatchSize, maxTimeToBlock); |
There was a problem hiding this comment.
It seems that we can block on the bufferpool longer than maxTimeToBlock. Claude suggested the following path that doesn't exist in RecordAccumulator.
Iteration A: needsNewBatch → blocking allocateChunks(_, maxTimeToBlock) succeeds after a wait. A concurrent thread created a batch meanwhile, so appendNewBatch's in-lock tryAppend returns needsBufferExtension → line 257 deallocates the stream, continue.
Iteration B: extension path does the non-blocking allocateChunks(gap, 0L) → BufferExhaustedException → closes the batch (line 168) → continue.
Iteration C: batch now closed → needsNewBatch → blocks again for a fresh full maxTimeToBlock.
There was a problem hiding this comment.
fixed, tracking remainingTimeToBlock along the whole path (which goes as you described above)
Iteration A consumes from it, B does not touch it because it's non-blocking, C consumes from what's left (fixing the gap)
| throw new IllegalArgumentException("totalSize must be positive: " + totalSize); | ||
|
|
||
| int chunkSize = poolableSize(); | ||
| int numChunks = (int) (((long) totalSize + chunkSize - 1L) / chunkSize); |
There was a problem hiding this comment.
int numChunks = (totalSize - 1) / chunkSize + 1; is overflow-safe even without the cast. The existing code is correct, so feel free to ignore this comment.
|
Thanks @junrao ! All comments addressed. |
| } finally { | ||
| // Update the remaining time to block. | ||
| nowMs = time.milliseconds(); | ||
| remainingTimeToBlock = Math.max(0L, remainingTimeToBlock - (nowMs - allocationStartMs)); |
There was a problem hiding this comment.
remainingTimeToBlock = Math.max(0L, remainingTimeToBlock - Math.max(0L, nowMs - allocationStartMs)); ?
| log.trace("Allocating {} byte chunked buffer ({} byte chunks) for topic {} partition {} with remaining timeout {}ms", | ||
| newBatchSize, chunkedFree.poolableSize(), topic, effectivePartition, remainingTimeToBlock); | ||
| List<ByteBuffer> initialChunks; | ||
| long allocationStartMs = time.milliseconds(); |
There was a problem hiding this comment.
This adds an extra time.milliseconds() call on the critical path. Could you do some perf test between full and incremental to verify there is no degradataion?
There was a problem hiding this comment.
Long story short, the extra time doesn't seem to have a relevant impact on append, but the repeated record size calculation on extensionBytesNeeded does, related to what we discussed in the comment above, but the numbers I ran now do show the impact even without headers. I included a partial improvement here now, and will add more as a follow-up with https://issues.apache.org/jira/browse/KAFKA-20859 (just avoiding it in this PR as it moves quite a few things)
Now the longer story, I ran a JMH comparison of the two accumulators append (will add the benchmarks in a separate PR)
1 - Batch creation path (every append to a different partition, so every one creates a batch): the extra time.milliseconds accounts for only 3.5% of the difference between the strategies at default batch.size (and it's per batch, not per record, above code is the new batch path). Overall on this path incremental is +21% at batch.size=16KB but −42% at bigger batch size 256KB (cold pool, not "steady" state)
2 - Steady-state append (records landing in an existing batch) is where the numbers show an impact, with 100-byte records: +36% at default batch.size and +45% at bigger batch.size 256KB. At 16KB, extensionBytesNeeded is around 91% of that gap (the record gets sized twice per append). The partial fix I added in this PR is to scoped the capacity check in ChunkedProducerBatch.tryAppend to a batch's first record only (the only append where the caller does not validate so we need to). That improves it to +17.6% at 16KB and +37.6% at 256KB. I will close the gap with what we know for now (follow-up with KAFKA-20859 right away), and run it all again there to see where we get.
Makes sense?
There was a problem hiding this comment.
For this PR, we just need to make sure the default full allocation strategy doesn't degrade. Do you have some perf numbers with the default allocation strategy without and with this PR?
| // drained and replaced — possibly by a split batch (a plain | ||
| // ProducerBatch), which can't take extension chunks. Only attach to a | ||
| // writable chunked batch; otherwise refund the chunks and re-evaluate. | ||
| if (last instanceof ChunkedProducerBatch && last.isWritable()) { |
There was a problem hiding this comment.
Claude found the following issue. So, the existing implementation of ProducerBatch.isWritable() is incorrect. It's just never used in production before.
ChunkedRecordAccumulator.java:210 gates the chunk attach on last.isWritable(), but that predicate doesn't mean what the call site needs:
ProducerBatch.isWritable() (ProducerBatch.java:570-572) is !recordsBuilder.isClosed()
MemoryRecordsBuilder.isClosed() (:919-921) is builtRecords != null — "records have been built", not "stream is open for appends"
NoCompression.wrapForOutput returns the stream itself, so closeForRecordAppends() → appendStream.close() really does reach ChunkedByteBufferOutputStream.close() and set closed = true
So a batch closed for appends reports isWritable() == true while its stream is already closed
As for the fix, Claude suggested replacing last.isWritable() with extensionBytesNeeded(...) > 0. It fixes this issue and also avoids the potential over-attaching issue below. If we do that, we probably want to remove isWritable since its implementation is incorrect and is not used in production.
There was a problem hiding this comment.
yeap, good point, makes sense. Fixed with the suggestion + test (and removed the isWritable too)
| Time time, | ||
| TransactionManager transactionManager, | ||
| BufferPool bufferPool) { | ||
| super(logContext, batchSize, compression, lingerMs, retryBackoffMs, retryBackoffMaxMs, |
There was a problem hiding this comment.
Could we verify the pool is INCREMENTAL mode with poolableSize() == CHUNK_SIZE?
| assertThrows(BufferExhaustedException.class, () -> p.allocateChunks(2 * chunkSize, 0)); | ||
|
|
||
| // Available memory must reflect only the chunk we deliberately hold; the request's | ||
| // first-chunk acquisition was rolled back. |
There was a problem hiding this comment.
first-chunk acquisition was rolled back
This is misleading since the 2-chunk allocation throws and nothing was acquired.
There was a problem hiding this comment.
agreed, updated the comments and aligned test name
|
Thanks @junrao ! All comments addressed (will open parallel PRs to publish the benchmarks and https://issues.apache.org/jira/browse/KAFKA-20859) |
| ProducerBatch last = dq.peekLast(); | ||
| // The batch may have changed while allocateChunks was off-lock: drained and | ||
| // replaced, closed for appends, filled to its limit, or already grown by a | ||
| // concurrent appender. The instanceof excludes a replacement that cannot take |
There was a problem hiding this comment.
How about the following?
// concurrent appender. extensionBytesNeeded is 0 in all of those cases, so it
// serves as both tests at once: the batch still needs chunks for this record,
// and it is still open (attaching to a stream closed for appends would throw).
// The instanceof covers the one case it cannot: a replacement that is a plain
// ProducerBatch (a split batch), which takes no chunks at all.
There was a problem hiding this comment.
Done, just minor tweak, that "extensionBytestNeeded is 0 in all those cases" was not true for all really, it could be > 0 in the first case (if the batch was drained and replaced, by another chunked batch that needs chunks for this record)
|
|
||
| // The extension chunks acquired for the closed batch must have been refunded, so the only | ||
| // memory still held beyond the first batch is what the new batch needed. | ||
| assertTrue(pool.availableMemory() < availableBeforeSecond, |
There was a problem hiding this comment.
This doesn't ensure that the extension chunks acquired for the closed batch must have been refunded.
There was a problem hiding this comment.
true, fixed to assert correctly
|
So I ran some benchmarks, focused on the shared code that default strategy runs (it's the append). Found an impact indeed, a small ~3% regression on the steady state, and this is what was behind it: It's fixed now, and it removed the ~3% diff. Details on the benchmark: ran in on the the accumulator append, with default and bigger batch.size (256 KB), without compression, and different message value sizes (benchmark code in separate PR). Will open it in another PR, didn't find any existing perf test that would cover this append path. |
yes, on it, I'm still running it, included compression already (draft benchmark I'm running here), will share numbers to confirm before merging. |
| log.trace("Pool exhausted while extending batch for topic {} partition {}; closing existing batch", | ||
| topic, partition); | ||
| synchronized (dq) { | ||
| ProducerBatch last = dq.peekLast(); |
There was a problem hiding this comment.
Claude suggested the following. Is it worth fixing? This can be done in a followup PR.
On pool exhaustion it re-takes the lock and closes dq.peekLast() — which may be a different, freshly-created batch than the one whose gap it sized, since the sizing happened before the failed off-lock acquire. Not corruption, but under exhaustion + concurrency it prematurely rolls healthy batches. Capture the batch identity and close only if it's still the same one.
Initial partial implementation for KIP-1332 (producer incremental
allocation strategy).
This PR includes:
uncompressed data only (no growth support), extra-copy on send (linked
chunks are flattened into a new buffer)
tests with the new strategy + new ones)
Support for compressed data and network layer improvements will come in
follow-up PRs.
Reviewers: Jun Rao junrao@gmail.com, Ken Huang s7133700@gmail.com,
Chia-Ping Tsai chia7712@gmail.com